fix(mcp): drop previous-transport keys on Claude server redeclaration - #3041
Conversation
Claude Code server entries were shallow-merged as {**old, **new}, so a
server redeclared under another transport kept the keys of the transport
it left behind. The surviving url also re-classified the entry as remote,
so the stdio normalisation never ran and a stale Authorization header
stayed in the Claude Code config.
Drop the keys describing the replaced transport before the merge, and
share one remote/stdio classifier between the merge and the normalizer so
both read an entry the same way. Keys APM does not manage, such as
hand-authored OAuth blocks, describe no transport and still survive.
Fixes microsoft#2994
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Existing mixed configurations can retain stale stdio keys, and the new runtime-config behavior is not documented.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
Fixes Claude MCP transport redeclarations so stale transport-specific fields are removed.
Changes:
- Adds transport-aware merge cleanup and shared classification.
- Adds regression tests for transport switches and key preservation.
- Records the fix in the changelog.
| File | Description |
|---|---|
src/apm_cli/adapters/client/claude.py |
Implements transport-aware merging and normalization. |
tests/unit/test_claude_mcp.py |
Tests transport changes and shallow-merge behavior. |
CHANGELOG.md |
Documents the fix. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Selecting the stale keys by comparing the stored entry's transport to the update's left entries written by an earlier release unrepaired: such an entry carries a url, so it classifies as remote, and a remote update was read as no transport change at all. The stdio keys, an env block among them, survived every reinstall. Select the stale keys from the update alone. An entry is then rewritten to carry only the transport its declaration names, whatever shape it had before, and the comparison branch disappears. Also document the rule where the guide describes what install writes to disk. Claude Code is the only target merging entries key by key, so it is the only one where a stored key can outlive its declaration.
|
Thank you for this pull request. It implements the accepted scope on #2994. CODEOWNERS review is already requested of danielmeppiel and sergio-sisternes-epam; that request is unchanged. This comment is advisory only and is not merge approval. Please wait for a human maintainer to review. Generated by autopilot-pr-triage-worker. This comment is AI-generated and may contain errors. |
Synchronize the accepted transport fix with current main without rewriting contributor commits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| python-architect | 0 | 2 | 0 | Adapter-local cleanup fits the ownership boundary; preserve partial remote updates and explicitly document repair-on-write. No automated tests were run. |
| test-coverage-expert | 0 | 2 | 0 | Adapter regression traps exist, but Claude transport transitions and legacy repair/no-op boundaries lack real-CLI lifecycle evidence on this candidate. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 3 follow-ups
- [test-coverage-expert] Add and execute one bounded Claude real-CLI lifecycle module covering transport changes and the repair-on-write boundary in project and user scopes. -- The missing secure-by-default preservation evidence is the highest-priority test gap. Use the real candidate engine and hermetic local declarations without mocking install, formatting, or adapter writes. Cover both transport directions, frozen stale-lock denial with unchanged snapshots, OAuth/unrelated/top-level/nested-project preservation, opposite-scope isolation, and byte-stable repeats. In the same module, prove that unchanged legacy mixed state is left alone, then repaired by an actual same-family declaration change. Verify the new traps detect removed pruning and restoration of the obsolete same-classification fast path. Existing adapter tests are useful lower-tier coverage, not a substitute for these lifecycle assertions.
- [python-architect] Preserve partial remote-update semantics and add automated headers-only HTTP and URL-only SSE regression traps. -- The isolated comparison shows a compatibility regression even though ordinary formatted declarations supply a type. Distinguish partial updates from explicit transport declarations, retain omitted same-family connection settings and type, and keep pruning on actual declarations so already-mixed entries still repair. This is a bounded correction inside the existing Claude helper, not a reason to change MCPIntegrator.
- [python-architect] Make the guide and helper docstring explicitly promise repair only when the server entry is written. -- The changelog already qualifies the behavior correctly. Repeating an unchanged self-defined installation with matching lock state can skip the adapter write and leave legacy mixed JSON untouched. State that limitation consistently; resolving documentation ambiguity must not expand the accepted no-op reconciliation scope.
Recommendation
I recommend a bounded in-PR revision: correct the partial-update regression, add and execute the both-scope lifecycle evidence, and align repair-on-write wording before returning for human review. This recommendation follows the concrete preservation defect and missing critical guardrail, not the historical thread status or a demand for automatic no-op repair. Reassess the resulting exact candidate with observed test and CI results; this local advisory grants no merge approval and leaves CODEOWNERS danielmeppiel and sergio-sisternes-epam in place.
Full per-persona findings
python-architect
- [recommended] Partial remote updates lose their existing transport and connection details at
src/apm_cli/adapters/client/claude.py:140
The merge previously preserved omitted keys, including type. _retained_previous_entry now treats any update without a URL or remote type as stdio, and both pruning sets unconditionally remove type. An isolated execution of the exact merge/normalization methods from base and candidate confirmed two regressions: updating an existing HTTP entry with only headers previously retained its URL and HTTP type, but now produces only headers plus type=stdio; updating an existing SSE entry with only url previously retained type=sse, but now drops type entirely. This conflicts with the shallow-merge preservation contract. The normal configure_mcp_server path currently receives an explicit type from CopilotClientAdapter._format_server_config, so this is a partial-update API regression, not evidence that ordinary formatted CLI redeclarations fail. The shared Claude helper remains the appropriate owner; no additional abstraction or integrator change is needed.
Suggested: Distinguish an explicit transport declaration from a partial update. When the update supplies no transport discriminator, preserve the previous family; preserve an omitted same-family type as well. Keep unconditional opposite-family pruning for actual declarations so already-mixed entries still repair. Add headers-only HTTP and URL-only SSE preservation regressions.
Proof (manual, manual-only):::-- Updating only a remote server's headers or URL no longer preserves its omitted connection settings. - [recommended] Make the unchanged-install exception explicit in the guide at
docs/src/content/docs/consumer/install-mcp-servers.md:137
The guide describes transport cleanup but does not explain when an existing mixed entry is actually rewritten. _check_self_defined_servers_needing_installation checks name presence, _detect_mcp_config_drift compares the declaration against stored lock state, and _install_self_defined_deps skips names absent from its install list. Consequently, an unchanged declaration present in every target with matching lock state can leave a legacy mixed entry untouched. CHANGELOG.md:23 correctly says 'the next install that writes it'; the helper docstring at claude.py:133-136 instead says 'the next install'. The approved no-op reconciliation boundary is coherent and should remain unchanged, but readers need the same repair-on-write qualification in the guide and helper documentation.
Suggested: State explicitly that cleanup occurs only when APM writes that server entry, and that repeating an unchanged self-defined installation with matching lock state may skip the write and therefore not repair an older mixed entry. Align the helper docstring with the changelog's existing qualification.
test-coverage-expert
- [recommended] Exercise transport redeclaration through the real CLI in both project and user scopes. at
tests/unit/test_claude_mcp.py:293
The six added TestClaudeTransportChange tests use real adapter file writes, but all construct user_scope=False and bypass declaration loading, lock drift detection, and install routing. The golden-schema tests cover fresh project/user writes and user-config preservation, not same-name transport changes. Narrow glob/rg probes of tests/unit/mcp.py, tests/integration/mcp.py and tests/integration/lifecycle.py found the transition traps only in test_claude_mcp.py. Read the matching lifecycle tests: test_required_mixed_primitives_survive_reinstall_without_state_loss does not change transport, and test_frozen_stale_mcp_lock_is_read_only_and_normal_install_repairs changes Cursor args rather than Claude transport. Thus lower-tier regression tests are present, but the user-facing transition and its preservation boundary lack an ApmLifecycle contract. This is a coverage gap, not an observed correctness or credential-disclosure failure; a bounded hermetic fixture path exists.
Suggested: Add test_transport_redeclaration_preserves_unowned_state to the proposed tests/integration/test_claude_mcp_transport_lifecycle.py, parametrized over project/user scope and HTTP->stdio/stdio->HTTP. Use ApmLifecycleRunner with a candidate-built engine, IsolatedApmEnvironment, and local registry:false declarations; do not mock install, formatting, or adapter writes. Sequence: apm install --target claude --no-policy [--global]; change the same-name declaration; snapshot; install with --frozen and the same scope/target options; assert nonzero exit, a diagnostic identifying the server/config mismatch, and unchanged native-config/lock snapshots; normal install; repeat normal install. Assert exact native entry shape: stdio type with no url/headers, or HTTP type with no command/args/env/cwd. Seed and compare OAuth/passthrough values, unrelated servers including an untouched mixed entry, top-level keys, nested projects, and the opposite-scope sentinel. Require byte-stable config and lock state on the final repeat. Include a same-family stdio update preserving an omitted cwd. Fold the frozen negative phase into these cases rather than adding a separate expensive suite. A cohesive Claude-specific module is preferable to expanding the unrelated mixed-primitives lifecycle test: this needs two scope roots and native-config sentinels. Apply the integration/e2e/lifecycle_smoke/requires_apm_binary/requires_e2e_mode marker contract. Keep the target bounded to Claude; no cross-target reconciliation is requested.
Proof (missing, e2e):tests/integration/test_claude_mcp_transport_lifecycle.py::test_transport_redeclaration_preserves_unowned_state-- Changing a Claude server's transport updates only that server's transport fields, in the selected scope, without damaging unrelated configuration. - [recommended] Pin the legacy mixed-entry repair-on-write boundary without requiring automatic no-op migration. at
tests/unit/test_claude_mcp.py:374
The direct adapter tests test_mixed_entry_from_earlier_release_is_repaired and test_mixed_entry_repaired_towards_stdio correctly exercise explicit rewrites; they cannot prove that an unchanged CLI install reaches the adapter. Read _install_self_defined_deps, _check_self_defined_servers_needing_installation, and _detect_mcp_config_drift: name presence plus matching manifest/lock state can skip writing even when native JSON is mixed. Narrow searches for mixed_entry, legacy mixed, transport transitions and no-op coverage found no real-CLI lifecycle for this distinction. Matching integrator tests either mock these seams or assert their return values in isolation. The accepted limitation is therefore supported by source inspection, not by an executed consumer scenario. The requested test should document the unchanged mixed-state skip, not turn it into a new reconciliation requirement. Severity is recommended because no in-scope implementation failure was reproduced.
Suggested: Add test_legacy_mixed_state_is_repaired_only_on_redeclaration to the same proposed lifecycle module, sharing its scope fixtures rather than creating another module. In project and user scopes, install a self-defined declaration to establish matching lock state; plant a legacy mixed native entry while preserving the manifest and lock; snapshot; run the identical install and assert byte-for-byte unchanged native config and durable state. Then change a real same-family declaration field (URL for remote, args for stdio), run install, and assert removal of the opposite family's keys with passthrough/unrelated state preserved; repeat and assert byte stability. Cover rewrites toward both families. The remote case must start with both url and stdio keys, so the old same-classification fast path would fail the test. Later mutation verification should show the transition test fails when pruning is removed and this remote mixed-rewrite case fails when the obsolete fast path is restored. No source mutation was performed in this review.
Proof (missing, e2e):tests/integration/test_claude_mcp_transport_lifecycle.py::test_legacy_mixed_state_is_repaired_only_on_redeclaration-- An unchanged install does not promise to migrate legacy mixed Claude state; an actual redeclaration repairs that entry and subsequent installs remain stable.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Generated by autopilot-pr-review-worker. This comment is AI-generated and may contain errors.
Address the initial panel's partial-update regression and add real isolated CLI coverage for transport changes, mixed-state repair, no-op installs, frozen denial and user configuration preservation. Keep shared no-op reconciliation unchanged and document repair-on-write. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| python-architect | 0 | 0 | 1 | 14578fc fixes compatible-type byte churn without changing merge values. No substantive architecture finding; synchronized full-suite evidence and exact-head GitHub CI remain outstanding. |
| test-coverage-expert | 0 | 0 | 0 | At 14578fc, 65 tests and 4 subtests passed. Byte-order regression traps pass without weakening expected bytes, pruning, or partial updates. No coverage findings. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Recommendation
I recommend ship_now as a software advisory for 14578fc: the CI-discovered regression is corrected, exact-head tests and workflows pass, and no additional substantive in-scope follow-up is identified. The orchestrator should publish the corrected head-specific evidence, then leave the decision with the existing human reviewers. This is neither PR approval nor permission to merge or enable auto-merge; any subsequent head change or new failure requires reassessment.
Full per-persona findings
python-architect
- [nit] The existing adapter structure remains appropriate; no design change requested at
src/apm_cli/adapters/client/claude.py:142
Design patterns - Used in this PR: Adapter + base/subclass -- ClaudeClientAdapter reuses CopilotClientAdapter formatting while retaining Claude-specific merge and normalization in the existing helper chain.
- Pragmatic suggestion: none -- the current shape is the simplest correct design at this scope.
test-coverage-expert
No findings.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Generated by autopilot-pr-review-worker. This comment is AI-generated and may contain errors.
Keep compatible transport types in their original insertion position while pruning opposite-family fields. Strengthen raw-byte repeat and partial-update order assertions; preserve existing registry lifecycle snapshots. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
598014b
into
microsoft:main


fix(mcp): remove stale Claude transport fields without losing user configuration
TL;DR
Fix #2994's same-name Claude MCP redeclaration: HTTP-to-stdio removes stale
url/headers, and stdio-to-HTTP removes stalecommand/args/env/cwd.The existing Claude merge path also repairs mixed entries when rewritten,
preserves partial updates and unmanaged configuration, and now has real CLI
lifecycle coverage in project and user scopes. Repeated writes also preserve
compatible field order, avoiding native JSON byte churn.
Important
Repair happens when APM writes the server. An unchanged self-defined
declaration with matching lock state can skip that write; reinstalling
unchanged is not an automatic mixed-entry migration.
Original implementation and mixed-entry fix: Eden (@edenfunf). The follow-up preserves
those commits and adds the partial-update and byte-order corrections with
lifecycle evidence.
Issue: #2994, already accepted; this does not replace required human review.
Problem (WHY)
redeclaration, including the stale header reported in [BUG] Claude MCP entry keeps the old url/headers when a server's transport changes from http to stdio #2994.
Claude's
type: "stdio"normalization. The reverse transition retainedstdio-only fields.
Unconditionally pruning without preserving partial-update semantics then
lost HTTP connection settings or an omitted SSE type.
reinserting a compatible
typemoved it afterurl. Parsed values matched,but the existing registry lifecycle's exact native bytes changed.
These are concrete state transitions, not a claim of observed credential
disclosure. The validation process follows Agent Skills'
"do the work, run a validator (a script, a reference checklist, or a self-check), fix any issues, and repeat until validation passes."
The executed regression mutations below establish which guards matter.
Approach (WHAT)
the old entry already contains both families.
typein its existing insertion position so unchangedvalid rewrites do not reorder native JSON.
Implementation (HOW)
src/apm_cli/adapters/client/claude.pytests/unit/test_claude_mcp.pytests/integration/test_claude_mcp_transport_lifecycle.pydocs/src/content/docs/consumer/install-mcp-servers.mdpackages/apm-guide/.apm/skills/apm-usage/commands.mdCHANGELOG.mdDiagrams
The dashed helper is the new cleanup stage; the existing no-write route remains unchanged.
flowchart LR subgraph Install["Install selection"] W{"Write required?"} K["Keep existing native config"] end subgraph Merge["Claude adapter"] R["_retained_previous_entry"] M["_merge_mcp_server_dicts"] N["_normalize_mcp_entry_for_claude_code"] end subgraph Output["Native config"] O["Write project or user JSON"] end W -->|No| K W -->|Yes| R R --> M M --> N N --> O classDef new stroke-dasharray: 5 5; class R new;The block was parsed and rendered with Mermaid CLI before publication.
Trade-offs
accepted adapter scope. Tests deliberately preserve the unchanged-install
skip instead of silently expanding it.
matching old fields; omitted compatible values and unmanaged keys survive.
This does not introduce recursive merging or normalize unrelated servers.
use this exact worktree's editable installation with isolated HOME/config,
cache and credentials. Tests inspect actual JSON and lock state; they do not
launch MCP servers or validate a packaged executable.
Benefits
nested project settings without writing the opposite scope.
compatible omitted settings and field order.
Validation
Evidence revision:
14578fc1ec57b1d5a2db75029c91ed61c63e2251.Merged base:
d68f052e60593a51fa1da2ce90a60f4da31f8398.All six authorized exact-head workflows completed successfully:
CI,
Merge Gate,
Docs,
CodeQL,
NOTICE and
Spec.
Lifecycle Smoke, both test shards, Windows compatibility, lint, architecture
ratchets and PR Binary Smoke passed. Docs deployment was skipped as expected;
the aggregate CodeQL check was neutral. Required human review remains
outstanding; GitHub reports
MERGEABLE/BLOCKED. No merge or auto-merge wasperformed.
Executed local checks and regression mutations
The independent coverage reviewer executed the following selection through
the isolated pytest wrapper, with JUnit output retained in the driver's
evidence artifacts. Local registry fixtures require the two environment
variables shown here:
APM_E2E_TESTS=1 APM_BINARY_PATH="$PWD/.venv/bin/apm" \ uv run --frozen --extra dev python -m pytest -p no:cacheprovider -q \ tests/unit/test_claude_mcp.py \ tests/integration/test_claude_mcp_schema_fidelity.py \ tests/integration/test_claude_mcp_transport_lifecycle.py \ tests/integration/test_mcp_dev_dependency_lifecycle.pyNo failures, errors or skips. This includes all eight self-defined CLI
cases, three existing registry lifecycle cases and six strengthened
partial/repeat cases. The registry tests and their expected bytes were not
changed to accommodate the fix.
All seven current CI-mirror lint gates and architecture boundaries passed
locally on this head. The deterministic exact-base/head owner detector
reported no registered canonical-owner touch.
8ad164d98ad164d98ad164d911aea75cAll mutations were restored. The recovery commit
11aea75cpassed128 tests plus 4 subtests, including
tests/quality, before the mainsynchronization. The current head has its own fresh 65-test plus 4-subtest
run and successful CI; historical runs and mutations are not relabeled
as current-head executions.
Scenario Evidence
The self-defined lifecycle cases run
apm install --target claude --no-policythrough the real Python CLI, parametrized over project and
--globalscopes.The existing registry cases use isolated local registry/Git fixtures and the
installed CLI entrypoint.
tests/integration/test_claude_mcp_transport_lifecycle.py::test_transport_redeclaration_preserves_unowned_statewith initialhttp(regression-trap for #2994)stdio.tests/integration/test_claude_mcp_transport_lifecycle.py::test_legacy_mixed_state_is_repaired_only_on_redeclaration(regression-trap for #2994)tests/integration/test_claude_mcp_transport_lifecycle.py::test_legacy_mixed_state_is_repaired_only_on_redeclarationtests/integration/test_claude_mcp_transport_lifecycle.py::test_transport_redeclaration_preserves_unowned_statetests/integration/test_claude_mcp_transport_lifecycle.py.tests/integration/test_claude_mcp_transport_lifecycle.py.tests/unit/test_claude_mcp.py::test_partial_updates_preserve_transporttests/unit/test_claude_mcp.py::test_partial_updates_preserve_transport, all six scope/transport cases (CI regression-trap).tests/integration/test_mcp_dev_dependency_lifecycle.py::test_dependency_dev_mcp_isolated_across_installed_lifecycle[claude](existing CI regression-trap).tests/integration/test_mcp_dev_dependency_lifecycle.py::test_stale_dependency_dev_mcp_repair_and_frozen_no_write(existing CI regression-trap).How to test
expect all selected cases to pass without skips.
both scopes and directions must remove only the opposite transport fields.
and byte-equal native config plus equal lifecycle snapshots on repeats.
phase: only the latter repairs the entry.
existing registry regression cases; dictionary equality alone is insufficient.
retain human CODEOWNER review before any merge.
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com